Skip to main content

TMProxies&DirectTableAccess

A proxy is a live, write-through view of a table inside a TableManager. Reading through it returns nested proxies; writing through it routes back into the manager exactly as Set would, firing all the normal events. This guide covers what proxies can and can't do, and how to safely work with the raw data underneath.

Getting a proxy

manager.Proxy is the root proxy; manager:GetProxy(path) returns the proxy for a nested table (or the raw value if the path isn't a table).

local manager = TableManager.new({ Player = { Health = 100 } })

manager.Proxy.Player.Health = 80        -- fires ValueChanged, like Set
print(manager.Proxy.Player.Health)      -- 80

What works

Proxies are userdata backed by metamethods, so most table syntax works transparently:

  • #proxy — length, via __len.
  • for k, v in proxy do — generic iteration, via __iter.
  • proxy.key / proxy.key = value — indexing and assignment.
  • proxy1 == proxy2 — two proxies wrapping the same underlying table are equal.
  • tostring(proxy) — readable identification.
local manager = TableManager.new({ Items = { "a", "b", "c" } })

print(#manager.Proxy.Items)             -- 3
for i, item in manager.Proxy.Items do   -- generic for, NOT ipairs
	print(i, item)
end

What doesn't work

Because a proxy is userdata, not a table, some operations that expect a real table fail:

  • pairs/ipairs — use generic for k, v in proxy do instead.
  • table.* functions (table.insert, table.remove, …) — use the manager's array methods (ArrayInsert, ArrayRemove, …), which also fire events.
  • proxy == originalTable — a proxy and its raw table have different identities; __eq only applies proxy-to-proxy. Compare against the raw value from Get instead (see below).
  • rawget/rawset — these bypass the metamethods.
  • Proxies as table keys — a proxy and its original are distinct keys. Pick one and use it consistently as a key.
-- Bad:
table.insert(manager.Proxy.Items, "d")     -- no-op; Items is a proxy
if manager.Proxy == manager.Raw then end   -- always false

-- Good:
manager:ArrayInsert("Items", "d")          -- fires ArrayInserted
if manager:Get("Items") == someRawTable then end -- Get returns the raw value

Multi-location references and DuplicateReferenceMode

Writing the same table to a second path is a supported feature. Which of two behaviors you get is set by DuplicateReferenceMode:

  • "allow" (default) — both paths share identity; a write through one is visible at the other, and each location fires its own events. A shared table has a single proxy reporting one "primary anchor" (whichever path created it first).
  • "copy" — the second write stores an independent deep clone, so the two paths diverge.
local a = TableManager.new(data, { DuplicateReferenceMode = "copy" })

Opting out of proxies

Pass EnableProxies = false to skip building the proxy graph entirely. Then manager.Proxy is nil and GetProxy errors — read with Get and write with Set/the array methods.

local manager = TableManager.new(data, { EnableProxies = false })
manager:Set("Player.Health", 80) -- proxies unavailable; Set still works

Bypassing the manager

If external code mutates manager.Raw (or a table you hold a reference to) directly, the manager doesn't see it and no events fire. The supported recovery is Flush(path), which diffs that branch and fires whatever changed — see the Flushing guide. Prefer writing through the API so this never comes up.


See also

Show raw api
{
    "functions": [],
    "properties": [],
    "types": [],
    "name": "TM Proxies & Direct Table Access",
    "desc": "A **proxy** is a live, write-through view of a table inside a\n[TableManager](/api/TableManager). Reading through it returns nested proxies;\nwriting through it routes back into the manager exactly as `Set` would, firing\nall the normal events. This guide covers what proxies can and can't do, and how\nto safely work with the raw data underneath.\n\n## Getting a proxy\n\n`manager.Proxy` is the root proxy; `manager:GetProxy(path)` returns the proxy\nfor a nested table (or the raw value if the path isn't a table).\n\n```lua\nlocal manager = TableManager.new({ Player = { Health = 100 } })\n\nmanager.Proxy.Player.Health = 80        -- fires ValueChanged, like Set\nprint(manager.Proxy.Player.Health)      -- 80\n```\n\n## What works\n\nProxies are userdata backed by metamethods, so most table syntax works\ntransparently:\n\n- `#proxy` — length, via `__len`.\n- `for k, v in proxy do` — generic iteration, via `__iter`.\n- `proxy.key` / `proxy.key = value` — indexing and assignment.\n- `proxy1 == proxy2` — two proxies wrapping the same underlying table are equal.\n- `tostring(proxy)` — readable identification.\n\n```lua\nlocal manager = TableManager.new({ Items = { \"a\", \"b\", \"c\" } })\n\nprint(#manager.Proxy.Items)             -- 3\nfor i, item in manager.Proxy.Items do   -- generic for, NOT ipairs\n\tprint(i, item)\nend\n```\n\n## What doesn't work\n\nBecause a proxy is userdata, not a table, some operations that expect a real\ntable fail:\n\n- **`pairs`/`ipairs`** — use generic `for k, v in proxy do` instead.\n- **`table.*` functions** (`table.insert`, `table.remove`, …) — use the\n  manager's array methods (`ArrayInsert`, `ArrayRemove`, …), which also fire\n  events.\n- **`proxy == originalTable`** — a proxy and its raw table have different\n  identities; `__eq` only applies proxy-to-proxy. Compare against the raw value\n  from `Get` instead (see below).\n- **`rawget`/`rawset`** — these bypass the metamethods.\n- **Proxies as table keys** — a proxy and its original are distinct keys. Pick\n  one and use it consistently as a key.\n\n```lua\n-- Bad:\ntable.insert(manager.Proxy.Items, \"d\")     -- no-op; Items is a proxy\nif manager.Proxy == manager.Raw then end   -- always false\n\n-- Good:\nmanager:ArrayInsert(\"Items\", \"d\")          -- fires ArrayInserted\nif manager:Get(\"Items\") == someRawTable then end -- Get returns the raw value\n```\n\n## Multi-location references and DuplicateReferenceMode\n\nWriting the same table to a second path is a supported feature. Which of two\nbehaviors you get is set by\n[`DuplicateReferenceMode`](/api/TableManager#DuplicateReferenceMode):\n\n- `\"allow\"` (default) — both paths **share identity**; a write through one is\n  visible at the other, and each location fires its own events. A shared table\n  has a single proxy reporting one \"primary anchor\" (whichever path created it\n  first).\n- `\"copy\"` — the second write stores an **independent deep clone**, so the two\n  paths diverge.\n\n```lua\nlocal a = TableManager.new(data, { DuplicateReferenceMode = \"copy\" })\n```\n\n## Opting out of proxies\n\nPass `EnableProxies = false` to skip building the proxy graph entirely. Then\n`manager.Proxy` is `nil` and `GetProxy` errors — read with `Get` and write with\n`Set`/the array methods.\n\n```lua\nlocal manager = TableManager.new(data, { EnableProxies = false })\nmanager:Set(\"Player.Health\", 80) -- proxies unavailable; Set still works\n```\n\n## Bypassing the manager\n\nIf external code mutates `manager.Raw` (or a table you hold a reference to)\ndirectly, the manager doesn't see it and no events fire. The supported recovery\nis `Flush(path)`, which diffs that branch and fires whatever changed — see the\nFlushing guide. Prefer writing through the API so this never comes up.\n\n---\n### See also\n\n- **[TM Flushing](/api/TM%20Flushing)** — `Flush` for surfacing bypassed writes.\n- **[TM Opaque Values](/api/TM%20Opaque%20Values)** — marking tables so the diff engine leaves them alone.\n- **[TM Getting Started](/api/TM%20Getting%20Started)** — reading and writing basics.",
    "source": {
        "line": 110,
        "path": "lib/tablemanager/src/Docs/TM_Proxies_And_Direct_Table_Access.luau"
    }
}